Skip to content

feat(events): canonical per-family payload on event routes beside the native envelope (#466) - #545

Merged
ScriptedAlchemy merged 4 commits into
mainfrom
feat/466-canonical-event-payload
Sep 4, 2026
Merged

feat(events): canonical per-family payload on event routes beside the native envelope (#466)#545
ScriptedAlchemy merged 4 commits into
mainfrom
feat/466-canonical-event-payload

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Fixes #466.

What

Every event route now receives a canonical, per-family canonical.payload beside the raw native envelope. AgentEventRouteProps<E> (and AgentEventCanonicalIdentity<E>) take the route's family as a type parameter and narrow payload to that family's fields; the bare AgentEventRouteProps keeps working with every field optional.

  • Shape. Each field is { value, nativeKey } | undefined (AgentEventPayloadField<T>): the decoded value beside the host key it was read from, so a route can tell mapped from missing and still name the host's spelling. A field the host did not send is absent — never defaulted, never inferred from another key. Two readings go beyond "read the key": Cursor tool_output (a JSON string) is parsed into toolResponse and kept as the string when it is not valid JSON; Cursor loop_count becomes the reentry boolean (> 0). Claude/Codex stop_hook_active is reentry as-is.
  • Admission rule. A field is on a family's payload only when ≥ 2 of the hosts that support the family report it (single-host families — Claude's task/*, file/change, …, Cursor's workspace/open — carry their defining fields). The rule is enforced by a test, which is how model ended up on the nine three-host families only (Claude sends it on SessionStart alone).
  • native is untouched, idempotencyKey still hashes only { event, native, target } (payload is derived, so a mapping change never re-identifies an event), and permissionDecision projection from fix(events): tool/before pass-through projects no decision instead of allow (#461) #481 is not touched.

Where the table lives

  • packages/agent-bundle/src/routes/events.ts — the one table: agentEventPayloadFieldTypes / agentEventPayloadFieldKinds (vocabulary + JSON shape), agentEventPayloadFields (per family, drives AgentEventPayload<E>), agentEventPayloadNativeKeys (per host × family → { nativeKey, decode? }). All exported from agent-bundle, agent-bundle/routes, agent-bundle/api.
  • packages/agent-bundle/src/events/payload.tsprojectEventPayload(event, native, target), called from createCanonicalEventProps, which every surface goes through (standalone hook wrapper, shared runtime IPC, agent-bundle/test, Workbench replay). Unknown/portable/composite-unresolved hosts get {}.
  • Each pinned capability table mirrors its host's mapping under hooks.eventRoutes.<event>.payload (claude-2.1.250.json, codex-0.147.0.json, cursor-2026-08-28.json) so the generated events reference renders the matrix; tests/event-payload.test.ts holds the JSON equal to the runtime table field for field, and rejects a payload on an unsupported row or a missing one on a supported row.
  • Generated docs: website/plugins/generated-reference.ts adds a "Canonical payload fields" section (one field × host table per family) to reference/events.

Payload shape per family (field × host → native key; = host never sends it)

Evidence: the live captures under fixtures/host-lineage/ (Claude 2.1.259, Codex 0.147.0, Cursor 3.18.25), the pinned Codex input schemas, validateNativeEventEnvelope, the Claude hooks reference ("Common input fields": model reaches SessionStart only; permission_mode "not all events"; agent_id/agent_type on every hook inside a subagent), and https://cursor.com/docs/hooks (cwd on tool events only; no permission mode; conversation_id). Cursor subagentStop.subagent_id is undocumented but observed on 3.18.25 and mapped if present.

session/start — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model model
source source source
tool/before — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
toolName tool_name tool_name tool_name
toolInput tool_input tool_input tool_input
toolUseId tool_use_id tool_use_id tool_use_id
tool/after — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
toolName tool_name tool_name tool_name
toolInput tool_input tool_input tool_input
toolUseId tool_use_id tool_use_id tool_use_id
toolResponse tool_response tool_response tool_output → json-string
stop — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
reentry stop_hook_active stop_hook_active loop_count → positive-count
lastAssistantMessage last_assistant_message last_assistant_message
agent/start — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id subagent_id
agentType agent_type agent_type subagent_type
model model model
agent/stop — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id subagent_id
agentType agent_type agent_type subagent_type
model model model
agentTranscriptPath agent_transcript_path agent_transcript_path agent_transcript_path
reentry stop_hook_active stop_hook_active loop_count → positive-count
lastAssistantMessage last_assistant_message last_assistant_message
workspace/open — cursor
field cursor
workspaceRoots workspace_roots
session/end — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
reason reason reason reason
prompt/submit — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
prompt prompt prompt prompt
tool/failure — claude, cursor
field claude cursor
sessionId session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path
toolName tool_name tool_name
toolInput tool_input tool_input
toolUseId tool_use_id tool_use_id
error error error_message
isInterrupt is_interrupt is_interrupt
compact/before — claude, codex, cursor
field claude codex cursor
sessionId session_id session_id conversation_id
cwd cwd cwd
transcriptPath transcript_path transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
model model model
trigger trigger trigger trigger
compact/after — claude, codex
field claude codex
sessionId session_id session_id
cwd cwd cwd
transcriptPath transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
trigger trigger trigger
permission/request — claude, codex
field claude codex
sessionId session_id session_id
cwd cwd cwd
transcriptPath transcript_path transcript_path
permissionMode permission_mode permission_mode
agentId agent_id agent_id
agentType agent_type agent_type
toolName tool_name tool_name
toolInput tool_input tool_input
permission/denied — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
toolName tool_name
toolInput tool_input
stop/failure — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
error error
reentry stop_hook_active
lastAssistantMessage last_assistant_message
file/change — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
filePath file_path
config/change — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
source source
filePath file_path
task/create — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
taskId task_id
taskSubject task_subject
taskDescription task_description
teammateName teammate_name
teamName team_name
task/complete — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
taskId task_id
taskSubject task_subject
taskDescription task_description
teammateName teammate_name
teamName team_name
agent/idle — claude
field claude
sessionId session_id
cwd cwd
transcriptPath transcript_path
permissionMode permission_mode
agentId agent_id
agentType agent_type
teammateName teammate_name
teamName team_name

Deliberately not modelled (single host, or unit unpinned): Cursor attachments, generation_id, workspace_roots outside workspace/open, duration/duration_ms (Cursor's duration unit is not pinned), status, parent_conversation_id, task/summary; Claude prompt_id, effort, background_tasks, session_crons, compact_summary, custom_instructions, permission_decision(_reason); Codex turn_id. All stay on native. Turn/generation ids are already request.lineage.generation.

Tests

  • tests/event-payload.test.ts (unit): projects every event in the three live captures through the real table and checks each payload field back against the envelope (nativeKey matches the mapping and the key exists; identity decodes deep-equal the native value; nothing mapped-and-present is dropped); the issue's acceptance — tool/before reading only toolName/toolInput under the Claude, Codex, and Cursor captures; stop reentry true for stop_hook_active: true and Cursor loop_count: 1; Cursor tool_output parsed / kept as string; Cursor session/start has no source/cwd/permissionMode, transcript_path: null stays null, wrong shapes are dropped, portable/plugin/unsupported-family → {}; idempotency key unchanged by the payload; JSON mirror ≡ runtime table; the ≥ 2-hosts admission rule.
  • tests/route-register-typegen.test.ts (typegen, against the published declarations): a route typed AgentEventRouteProps<'tool/after'> registers RegisteredRouteInput<'event:tool/after'>['canonical'] as AgentEventCanonicalIdentity<'tool/after'> and its payload as AgentEventPayload<'tool/after'>; payload.reentry on it is a compile error; passing a stop input to it is a compile error; createEventRouteInput narrows.
  • tests/generated-route-server.test.ts: the real compiled hook wrapper → shared runtime → route path on Claude and Cursor now asserts the route saw toolName@tool_name and the parsed toolResponse (tool_response object on Claude, tool_output JSON string parsed on Cursor) while native kept the raw string.
  • Route-unit: examples/worktree-proximity (10), examples/rsc-agent-runtime (3), examples/host-test (7) via the new createEventRouteInput; repo route-unit pool 58/58; projection pool 158/158; Workbench lifecycle client/model/page tests updated (payload rows + strict schema).

Consumers

  • agent-bundle/test gains createEventRouteInput(event, native, { host, nativeEvent?, hostContractRevision?, validate? }) — validates per host/event like the wrapper and returns the typed { canonical, native } the harness takes. Hand-built canonical objects must now include payload (the only breaking-ish edge; all in-repo callers migrated to the helper).
  • Examples: worktree-proximity (src/event-support.ts now takes AgentEventPayloadcarriedChild, extractIntent, actorForWorktree; the five routes are typed to their families and no longer take native), rsc-agent-runtime (src/events/tool/after.tsx reads cwd/sessionId/toolName/toolInput from the payload; only editedPath — Claude file_path vs Codex apply_patch header — stays host-specific in hook/normalize.ts, still shared with the hand-rolled hook CLI). host-test keeps reading native on purpose: it is the probe that records the raw envelope, and its captures now include canonical.payload automatically. hooks-and-scripts has no event route (config hooks only; that surface is feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533's).
  • Workbench: the Lifecycles evidence panel lists the mapped payload (field → value · nativeKey) under "Canonical payload"; the strict replay schema admits exactly { nativeKey, value } per field.

cargo-hauler sites that become deletable once pinned (not edited here)

src/lib/event-support.ts @ current main:

  • :54-67 beforeShellEventFromcanonical.payload.{cwd,sessionId,toolInput,toolName,toolUseId}?.value
  • :69-77 toolResponseFrom (object vs Cursor JSON string) → canonical.payload.toolResponse?.value
  • :79-85 afterShellEventFrom → same as above plus toolResponse
  • :87-96 stopHookActiveFrom (stop_hook_active vs loop_count) → canonical.payload.reentry?.value
  • :98-105 stopHoldEventFromcanonical.payload.{sessionId,reentry}
  • :16-33 nativeString / nativeRecord / parseJsonRecord helpers → unused after the above
  • :45-52 hookContextFrom stays (it is provenance, already on canonical.provenance), and :124 decisionValue stays, as the issue predicted.

movie-library (/fast/projects/agent-plugins/movie-library, 15e9459): its only event route src/events/session/start.tsx reads neither native nor canonical, so nothing becomes deletable there.

Coordination

Docs

website/docs/{en,zh}/guide/authoring/hooks.mdx (new "The canonical payload" section, route example typed to its family, createEventRouteInput), website/docs/{en,zh}/guide/development/testing.mdx, docs/entry-conventions.md (event-route contract row), examples/rsc-agent-runtime/README.md; generated reference/events gains the per-family matrix. pnpm docs:site:build passes (dead-link/anchor/parity).

Changeset

.changeset/466-canonical-event-payload.mdagent-bundle: minor. Opened as patch (the runtime, artifact, wrapper, and output contracts are unchanged; native is unchanged; a route typed with the bare AgentEventRouteProps compiles unchanged), then raised to minor on the reviewer's P1: payload is a required property of the public AgentEventCanonicalIdentity, so a downstream test or harness that constructs the identity by hand — every in-repo example did until this PR, and cargo-hauler's tests likely do — stops compiling until it adds one, which is consumer action under the pre-1.0 rule (minor = breaking). The fix on the consumer side is createEventRouteInput.

Checks

pnpm typecheck, pnpm lint clean; pnpm test:unit 3266/3266; pnpm test:route-unit 58/58; pnpm test:projection 158/158; integration subset (generated-route-server event tests, lifecycle-replay-dev-server, target-hook-contract, hook-playground-service, public-api, route-register-typegen, worktree-proximity-journeys) green; pnpm docs:site:build passes; the three examples' typecheck + route-unit suites pass.

Review status

No PR comments are posted from this task; review threads are answered by commits and recorded here.

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 45403a2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T07:22:45.080873Z 6361fd7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@545
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@545
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@545

commit: 45403a2

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6361fd7ce8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -0,0 +1,5 @@
---
"agent-bundle": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the required canonical property as a minor change

Existing consumers that construct AgentEventCanonicalIdentity or event-route test inputs must now add the required canonical.payload property, so previously valid code can fail to compile after this release; the changeset summary itself acknowledges that break. Publishing it as a patch violates this repository's pre-1.0 rule and makes a breaking API change available through patch upgrades, so change this changeset to minor.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment on lines +143 to +144
export const agentEventPayloadFields = Object.freeze({
'agent/idle': [...sessionFields, 'teammateName', 'teamName'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deep-freeze the exported payload field arrays

When a JavaScript consumer, plugin, or casted TypeScript caller mutates an entry such as agentEventPayloadFields['tool/before'], Object.freeze protects only the outer record; the nested arrays remain mutable, and several families even share the same array instance. Because projectEventPayload iterates this exported table directly, a splice or push can silently change canonical payloads for all later route invocations in the process; use the repository's deepFreeze helper or freeze each array before exporting the table.

AGENTS.md reference: AGENTS.md:L17-L22

Useful? React with 👍 / 👎.

@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/466-canonical-event-payload branch 2 times, most recently from 51b603b to 6971913 Compare September 4, 2026 07:49
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 08:17
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/466-canonical-event-payload branch 2 times, most recently from f7b1e8a to 35bd89d Compare September 4, 2026 17:32
… the native envelope (#466)

AgentEventRouteProps<E>.canonical.payload carries the fields at least two
hosts report for the route's family — tool name/input/response, session id,
transcript path, cwd, prompt, agent id/type, stop re-entry, … — each as
{ value, nativeKey } naming the host key it was read from and absent when the
host did not send it. The per-family table (agentEventPayloadFields) and the
per-host key table (agentEventPayloadNativeKeys) live in routes/events.ts;
events/payload.ts projects through them inside createCanonicalEventProps, so
the standalone wrapper, the shared runtime, agent-bundle/test, and the
Workbench replay all agree. Each pinned capability table mirrors its host's
mapping under hooks.eventRoutes.<event>.payload (held equal by
tests/event-payload.test.ts) and the generated events reference renders the
field × host → native key matrix per family. agent-bundle/test gains
createEventRouteInput; worktree-proximity and rsc-agent-runtime read
canonical.payload instead of hand-parsing native; host-test keeps recording
native as the host-specific example.
…required canonical.payload a minor bump (review)
…l payload and pin the HookEvent field overlap (rebase over #542, #533)
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/466-canonical-event-payload branch from 35bd89d to bb7db6b Compare September 4, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Event routes: expose a canonical per-family payload (tool name/input/response, session id, stop re-entry) beside the raw native envelope

1 participant